Skip to content

fix(cli): stop update and restore materializing marketplace plugins - #556

Merged
blafourcade merged 2 commits into
nextfrom
fix/marketplace-mode-materialization
Jul 29, 2026
Merged

fix(cli): stop update and restore materializing marketplace plugins#556
blafourcade merged 2 commits into
nextfrom
fix/marketplace-mode-materialization

Conversation

@blafourcade

@blafourcade blafourcade commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🎯 What & why

claude, codex and copilot set translationMode: "marketplace", so resolveTranslator hands them ModeAMarketplaceTranslator, whose contract is to register a plugin reference and write nothing — its addPlugin records an empty files set, and its docstring says so explicitly.

Update and restore did not honour that. Both narrowed the resolved translator with instanceof BuiltTreeMaterializationTranslator and, for anything else, fell through to a raw materializing path. So a plugin that install deliberately left unmaterialized got its files written to disk and recorded in the manifest the first time the user ran plugin update or restore — leaving it both natively registered and materialized.

This is the "double matérialisation plugin au restore" suspicion from the original cartography, with a concrete mechanism.

The guard that looked like it covered this didn't. Update had:

const isLocalMarketplace = plugin.source.kind === "local" && plugin.marketplace !== undefined;

A github-hosted marketplace resolves plugin sources to kind: "git-subdir", never "local" — so it never fired for the case that matters. Restore had no guard at all.

🛠️ How it works

Both now dispatch on what resolvePluginTranslator returns for the tool, rather than on the translator's concrete class, so a tool's translation mode decides the behaviour in one place. No toolId === "claude" branching.

materializeViaBuiltTree widens to materializeViaTranslator, taking the PluginTranslator interface, and both call sites share it rather than repeating the logic.

Install was already correct and is unchanged.

🧹 Cleanup for users already affected

Anyone who ran update or restore before this has stray files on disk. Update self-heals — it deletes old manifest files before rebuilding — but only when a newer version exists upstream; on the latest version it returns early and never clears them. Restore had no delete step, so those files orphaned once the manifest entry emptied.

Restore now clears them, making it the reliable path for a plugin already on the latest version.

The delete is bounded three ways, since this removes files from a user's disk:

  1. it runs only when the resolved translator reports mode === "marketplace" — only ModeAMarketplaceTranslator does; both materializing translators report "flat", so built-tree and flat installs cannot reach it;
  2. it iterates the plugin's own manifest keys, never a directory scan or glob, so it cannot touch a file the plugin never wrote;
  3. it joins those keys to the base dir from resolvePluginBaseDir, not an assumed project root.

A file placed inside the same plugin directory but absent from the manifest survives untouched, with a test asserting exactly that. An empty manifest entry makes the whole thing a no-op.

deleteOldFiles moves from a private method on PluginUpdateUseCase to a shared helper used by both. Update's call stays unconditional: it also clears files dropped between versions on the built-tree and translate paths, which is a different concern.

🧪 How to verify

2152/2152 pass (2146 plus 6), tsc --noEmit clean, golden baseline unchanged.

Verified against pre-fix code: 3 of 4 materialization tests fail there with the predicted symptom (expected 6 to be 0, 6 being the fixture's file count). Forcing the cleanup guard false fails both cleanup tests with expected 'stray content' to be undefined.

Checked fact — cursor and opencode cannot reach either path. installScope: "user" and translationMode: "flat" are matched by earlier branches in resolveTranslator, so they only ever get BuiltTreeMaterializationTranslator or null. A flat-mode regression test covers it, since a shared code path is the main risk of this fix.

Committed with --no-verify: biome OOMs on this machine; each changed file was checked individually and reports no fixes.

claude, codex and copilot set translationMode "marketplace", so
resolveTranslator hands them ModeAMarketplaceTranslator, whose contract is
to register a plugin reference and write nothing: its addPlugin records an
empty files set.

Update and restore did not honour that. Both narrowed the resolved
translator with `instanceof BuiltTreeMaterializationTranslator` and, for
anything else, fell through to a raw materializing path. So a plugin that
install deliberately left unmaterialized got its files written to disk and
recorded in the manifest the first time the user ran update or restore,
leaving it both natively registered and materialized.

Update's `isLocalMarketplace` guard looked like it covered this but did not:
a github-hosted marketplace resolves plugin sources to kind "git-subdir",
never "local", so the guard never fired for the case that matters. Restore
had no guard at all.

Both now dispatch on what resolvePluginTranslator returns for the tool
rather than on the translator's concrete class, so a tool's translation mode
decides the behaviour in one place. materializeViaBuiltTree widens to
materializeViaTranslator, taking the PluginTranslator interface, and both
call sites share it rather than repeating the logic.

Install was already correct and is unchanged.

cursor and opencode cannot reach this path: installScope "user" and
translationMode "flat" are matched by earlier branches in resolveTranslator,
so they only ever get BuiltTreeMaterializationTranslator or null. A flat
mode regression test covers it.

2150/2150 pass (2146 plus 4), tsc clean, golden baseline unchanged.

The new tests were verified against the pre-fix code: 3 of 4 fail there with
the predicted symptom, the flat-mode guard passes in both states.

Known gap, not fixed here: a user who already ran update or restore has
stray files on disk. Update self-heals when a newer version exists, because
it deletes the old manifest files first, but it never runs when no version
bump is available, and restore has no delete step at all so it leaves the
files orphaned once the manifest entry goes empty. Adding a symmetric delete
to restore would fix it and would also delete files on disk, so it needs a
decision rather than a silent implementation.
Users who ran update or restore before the previous commit have stray
materialized files for marketplace-mode plugins. Update self-heals, because
it deletes the old manifest files before rebuilding, but only when a newer
version exists upstream: on the latest version it returns early and never
clears them. Restore had no delete step at all, so once the manifest entry
went empty those files were orphaned and untracked.

Restore now clears them, which makes it the reliable path for a plugin
already on the latest version.

The delete is bounded three ways. It runs only when the resolved translator
reports mode "marketplace", which only ModeAMarketplaceTranslator does; both
materializing translators report "flat", so built-tree and flat installs
cannot reach it. It iterates the plugin's own manifest keys rather than
scanning a directory, so it cannot touch a file the plugin never wrote. And
it joins those keys to the plugin base dir from resolvePluginBaseDir rather
than assuming the project root.

A file placed inside the same plugin directory but absent from the manifest
survives untouched, with a test asserting exactly that. An empty manifest
entry makes the whole thing a no-op.

deleteOldFiles moves from a private method on PluginUpdateUseCase to a
shared helper in plugin-helpers.ts, used by both. Update's call stays
unconditional: it also clears files dropped between versions on the
built-tree and translate paths, so it is not the same concern as this
cleanup.

2152/2152 pass (2150 plus 2), tsc clean, golden baseline unchanged.

Mutation-tested by forcing the guard false: both new tests fail with
"expected 'stray content' to be undefined".
@blafourcade
blafourcade merged commit 22a450e into next Jul 29, 2026
14 checks passed
@blafourcade
blafourcade deleted the fix/marketplace-mode-materialization branch July 29, 2026 08:25
blafourcade added a commit that referenced this pull request Jul 31, 2026
…556)

* fix(cli): stop update and restore materializing marketplace plugins

claude, codex and copilot set translationMode "marketplace", so
resolveTranslator hands them ModeAMarketplaceTranslator, whose contract is
to register a plugin reference and write nothing: its addPlugin records an
empty files set.

Update and restore did not honour that. Both narrowed the resolved
translator with `instanceof BuiltTreeMaterializationTranslator` and, for
anything else, fell through to a raw materializing path. So a plugin that
install deliberately left unmaterialized got its files written to disk and
recorded in the manifest the first time the user ran update or restore,
leaving it both natively registered and materialized.

Update's `isLocalMarketplace` guard looked like it covered this but did not:
a github-hosted marketplace resolves plugin sources to kind "git-subdir",
never "local", so the guard never fired for the case that matters. Restore
had no guard at all.

Both now dispatch on what resolvePluginTranslator returns for the tool
rather than on the translator's concrete class, so a tool's translation mode
decides the behaviour in one place. materializeViaBuiltTree widens to
materializeViaTranslator, taking the PluginTranslator interface, and both
call sites share it rather than repeating the logic.

Install was already correct and is unchanged.

cursor and opencode cannot reach this path: installScope "user" and
translationMode "flat" are matched by earlier branches in resolveTranslator,
so they only ever get BuiltTreeMaterializationTranslator or null. A flat
mode regression test covers it.

2150/2150 pass (2146 plus 4), tsc clean, golden baseline unchanged.

The new tests were verified against the pre-fix code: 3 of 4 fail there with
the predicted symptom, the flat-mode guard passes in both states.

Known gap, not fixed here: a user who already ran update or restore has
stray files on disk. Update self-heals when a newer version exists, because
it deletes the old manifest files first, but it never runs when no version
bump is available, and restore has no delete step at all so it leaves the
files orphaned once the manifest entry goes empty. Adding a symmetric delete
to restore would fix it and would also delete files on disk, so it needs a
decision rather than a silent implementation.

* fix(cli): clear plugin files a marketplace tool should never have had

Users who ran update or restore before the previous commit have stray
materialized files for marketplace-mode plugins. Update self-heals, because
it deletes the old manifest files before rebuilding, but only when a newer
version exists upstream: on the latest version it returns early and never
clears them. Restore had no delete step at all, so once the manifest entry
went empty those files were orphaned and untracked.

Restore now clears them, which makes it the reliable path for a plugin
already on the latest version.

The delete is bounded three ways. It runs only when the resolved translator
reports mode "marketplace", which only ModeAMarketplaceTranslator does; both
materializing translators report "flat", so built-tree and flat installs
cannot reach it. It iterates the plugin's own manifest keys rather than
scanning a directory, so it cannot touch a file the plugin never wrote. And
it joins those keys to the plugin base dir from resolvePluginBaseDir rather
than assuming the project root.

A file placed inside the same plugin directory but absent from the manifest
survives untouched, with a test asserting exactly that. An empty manifest
entry makes the whole thing a no-op.

deleteOldFiles moves from a private method on PluginUpdateUseCase to a
shared helper in plugin-helpers.ts, used by both. Update's call stays
unconditional: it also clears files dropped between versions on the
built-tree and translate paths, so it is not the same concern as this
cleanup.

2152/2152 pass (2150 plus 2), tsc clean, golden baseline unchanged.

Mutation-tested by forcing the guard false: both new tests fail with
"expected 'stray content' to be undefined".
blafourcade added a commit that referenced this pull request Jul 31, 2026
…556)

* fix(cli): stop update and restore materializing marketplace plugins

claude, codex and copilot set translationMode "marketplace", so
resolveTranslator hands them ModeAMarketplaceTranslator, whose contract is
to register a plugin reference and write nothing: its addPlugin records an
empty files set.

Update and restore did not honour that. Both narrowed the resolved
translator with `instanceof BuiltTreeMaterializationTranslator` and, for
anything else, fell through to a raw materializing path. So a plugin that
install deliberately left unmaterialized got its files written to disk and
recorded in the manifest the first time the user ran update or restore,
leaving it both natively registered and materialized.

Update's `isLocalMarketplace` guard looked like it covered this but did not:
a github-hosted marketplace resolves plugin sources to kind "git-subdir",
never "local", so the guard never fired for the case that matters. Restore
had no guard at all.

Both now dispatch on what resolvePluginTranslator returns for the tool
rather than on the translator's concrete class, so a tool's translation mode
decides the behaviour in one place. materializeViaBuiltTree widens to
materializeViaTranslator, taking the PluginTranslator interface, and both
call sites share it rather than repeating the logic.

Install was already correct and is unchanged.

cursor and opencode cannot reach this path: installScope "user" and
translationMode "flat" are matched by earlier branches in resolveTranslator,
so they only ever get BuiltTreeMaterializationTranslator or null. A flat
mode regression test covers it.

2150/2150 pass (2146 plus 4), tsc clean, golden baseline unchanged.

The new tests were verified against the pre-fix code: 3 of 4 fail there with
the predicted symptom, the flat-mode guard passes in both states.

Known gap, not fixed here: a user who already ran update or restore has
stray files on disk. Update self-heals when a newer version exists, because
it deletes the old manifest files first, but it never runs when no version
bump is available, and restore has no delete step at all so it leaves the
files orphaned once the manifest entry goes empty. Adding a symmetric delete
to restore would fix it and would also delete files on disk, so it needs a
decision rather than a silent implementation.

* fix(cli): clear plugin files a marketplace tool should never have had

Users who ran update or restore before the previous commit have stray
materialized files for marketplace-mode plugins. Update self-heals, because
it deletes the old manifest files before rebuilding, but only when a newer
version exists upstream: on the latest version it returns early and never
clears them. Restore had no delete step at all, so once the manifest entry
went empty those files were orphaned and untracked.

Restore now clears them, which makes it the reliable path for a plugin
already on the latest version.

The delete is bounded three ways. It runs only when the resolved translator
reports mode "marketplace", which only ModeAMarketplaceTranslator does; both
materializing translators report "flat", so built-tree and flat installs
cannot reach it. It iterates the plugin's own manifest keys rather than
scanning a directory, so it cannot touch a file the plugin never wrote. And
it joins those keys to the plugin base dir from resolvePluginBaseDir rather
than assuming the project root.

A file placed inside the same plugin directory but absent from the manifest
survives untouched, with a test asserting exactly that. An empty manifest
entry makes the whole thing a no-op.

deleteOldFiles moves from a private method on PluginUpdateUseCase to a
shared helper in plugin-helpers.ts, used by both. Update's call stays
unconditional: it also clears files dropped between versions on the
built-tree and translate paths, so it is not the same concern as this
cleanup.

2152/2152 pass (2150 plus 2), tsc clean, golden baseline unchanged.

Mutation-tested by forcing the guard false: both new tests fail with
"expected 'stray content' to be undefined".
@aidd-bot aidd-bot Bot mentioned this pull request Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant